spb/airiskindex Public
The most methodologically rigorous, fully transparent AI job-exposure index.
TypeScript 88%
Python 6.1%
SQL 2.7%
CSS 1.2%
JavaScript 0.9%
Shell 0.8%
1// File: page.tsx2// Path: apps/web/app/occupations/[code]/page.tsx3// Project: AI Risk Index — airiskindex.io4// Author: Simon-Pierre Boucher5// Contact: contact@spboucher.ai6// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8// Description: Occupation detail: sub-score tiles, dimension breakdown, per-task rater audit trail.910import Link from "next/link";11import { notFound } from "next/navigation";12import { prisma } from "@airiskindex/db";13import {14 DIMENSIONS,15 HIGH_EXPOSURE_THRESHOLD,16 INVERTED_DIMENSIONS,17 WEIGHTS,18 pressure,19 type DimensionKey,20} from "@airiskindex/scoring";21import {22 DistributionChart,23 ScoreBar,24 ShareMeter,25 SubScoreDotPlot,26} from "@/components/score-marks";27import { formatWage, socGroupName } from "@/lib/soc-groups";2829export const dynamic = "force-dynamic";3031const DIMENSION_LABELS: Record<DimensionKey | "augmentation", string> = {32 automatability: "Task automatability",33 feasibility: "Technical feasibility today",34 cost_ratio: "Cost vs. human wage",35 barriers: "Adoption barriers",36 adoption_velocity: "Sector adoption velocity",37 augmentation: "Augmentation potential",38};3940interface DimensionAggregate {41 dimension: DimensionKey;42 weight: number;43 inverted: boolean;44 pressure: number; // 0–100, orientation applied45 meanRating: number; // raw 1–5 panel mean46}4748async function loadOccupation(code: string) {49 const occupation = await prisma.occupation.findUnique({50 where: { code },51 include: { tasks: { orderBy: { importance: "desc" } } },52 });53 if (!occupation) return null;5455 const score = await prisma.occupationScore.findFirst({56 where: { occupationCode: code },57 orderBy: { run: { createdAt: "desc" } },58 include: { run: true },59 });6061 const taskIds = occupation.tasks.map((task) => task.id);62 const [taskScores, ratings] = await Promise.all([63 score64 ? prisma.taskScore.findMany({ where: { runId: score.runId, taskId: { in: taskIds } } })65 : Promise.resolve([]),66 prisma.taskRating.findMany({67 where: { taskId: { in: taskIds } },68 select: { taskId: true, dimension: true, model: true, rating: true, rationale: true },69 orderBy: [{ dimension: "asc" }, { model: "asc" }],70 }),71 ]);7273 let rank: number | null = null;74 let scoredTotal: number | null = null;75 if (score) {76 [rank, scoredTotal] = await Promise.all([77 prisma.occupationScore.count({78 where: { runId: score.runId, substitution: { gt: score.substitution } },79 }),80 prisma.occupationScore.count({ where: { runId: score.runId } }),81 ]);82 rank += 1;83 }8485 const [related, indexScores] = await Promise.all([86 score87 ? prisma.occupationScore.findMany({88 where: {89 runId: score.runId,90 occupationCode: { startsWith: code.slice(0, 2), not: code },91 },92 orderBy: { substitution: "desc" },93 take: 5,94 include: { occupation: { select: { code: true, title: true } } },95 })96 : Promise.resolve([]),97 score98 ? prisma.occupationScore.findMany({99 where: { runId: score.runId },100 select: { substitution: true },101 })102 : Promise.resolve([]),103 ]);104105 return { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores };106}107108function toBins(values: number[], binCount = 20): number[] {109 const bins = Array.from({ length: binCount }, () => 0);110 for (const value of values) {111 bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1;112 }113 return bins;114}115116function aggregateDimensions(117 tasks: Array<{ id: string; importance: number | null }>,118 ratings: Array<{ taskId: string; dimension: string; rating: number }>,119): DimensionAggregate[] {120 const out: DimensionAggregate[] = [];121 for (const dimension of DIMENSIONS) {122 let weightSum = 0;123 let ratingSum = 0;124 for (const task of tasks) {125 const values = ratings126 .filter((row) => row.taskId === task.id && row.dimension === dimension)127 .map((row) => row.rating);128 if (values.length === 0) continue;129 const mean = values.reduce((a, b) => a + b, 0) / values.length;130 const weight = task.importance ?? 3;131 ratingSum += mean * weight;132 weightSum += weight;133 }134 if (weightSum === 0) continue;135 const meanRating = ratingSum / weightSum;136 out.push({137 dimension,138 weight: WEIGHTS[dimension],139 inverted: INVERTED_DIMENSIONS.has(dimension),140 pressure: 100 * pressure(dimension, meanRating),141 meanRating,142 });143 }144 return out;145}146147export default async function OccupationPage({148 params,149}: {150 params: { code: string };151}): Promise<JSX.Element> {152 const data = await loadOccupation(params.code);153 if (!data) notFound();154 const { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores } = data;155156 const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry]));157 const ratingsByTask = new Map<string, typeof ratings>();158 for (const row of ratings) {159 const list = ratingsByTask.get(row.taskId) ?? [];160 list.push(row);161 ratingsByTask.set(row.taskId, list);162 }163 const rankedTasks = [...occupation.tasks].sort(164 (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1),165 );166 const dimensions = aggregateDimensions(occupation.tasks, ratings);167 const wage = formatWage(occupation.medianWageCents);168169 return (170 <main className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-12">171 <nav className="text-sm text-[var(--ink-2)]">172 <Link href="/#ranking" className="hover:text-[var(--ink)]">173 Ranking174 </Link>{" "}175 /{" "}176 <Link177 href={`/occupations#g${occupation.code.slice(0, 2)}`}178 className="hover:text-[var(--ink)]"179 >180 {socGroupName(occupation.code)}181 </Link>182 </nav>183184 <div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">185 <h1 className="text-3xl font-bold tracking-tight">{occupation.title}</h1>186 <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs text-[var(--muted)]">187 {occupation.code}188 </span>189 </div>190 <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-[var(--ink-2)]">191 {wage && <span>Median wage {wage}</span>}192 {occupation.employment != null && (193 <span>{occupation.employment.toLocaleString("en-US")} employed (US)</span>194 )}195 {rank != null && scoredTotal != null && (196 <span>197 Rank <strong className="text-[var(--ink)]">#{rank}</strong> of {scoredTotal} scored ·198 top {Math.max(1, Math.round((rank / scoredTotal) * 100))}% by substitution199 </span>200 )}201 </div>202 {occupation.description && (203 <p className="mt-3 max-w-2xl leading-relaxed text-[var(--ink-2)]">204 {occupation.description}205 </p>206 )}207208 {!score ? (209 <div className="mt-10 card p-8 text-sm text-[var(--ink-2)]">210 This occupation has not been scored yet — its {occupation.tasks.length} tasks are queued211 for the multi-model rater panel. Task statements are listed below.212 <ul className="mt-4 list-inside list-disc space-y-1">213 {occupation.tasks.slice(0, 20).map((task) => (214 <li key={task.id}>{task.statement}</li>215 ))}216 </ul>217 </div>218 ) : (219 <>220 <section className="mt-10">221 <div className="card p-5 sm:p-6">222 <div className="flex flex-wrap items-baseline justify-between gap-2">223 <h2 className="text-lg font-semibold tracking-tight">Sub-scores</h2>224 <p className="text-xs text-[var(--muted)]">225 0–100 · band = confidence interval from rater disagreement226 </p>227 </div>228 <div className="mt-4">229 <SubScoreDotPlot230 rows={[231 {232 label: "Substitution",233 band: {234 low: score.substitutionLow,235 score: score.substitution,236 high: score.substitutionHigh,237 },238 },239 {240 label: "Exposure",241 band: {242 low: score.exposureLow,243 score: score.exposure,244 high: score.exposureHigh,245 },246 },247 {248 label: "Augmentation",249 band: {250 low: score.augmentationLow,251 score: score.augmentation,252 high: score.augmentationHigh,253 },254 },255 ]}256 />257 </div>258 <div className="mt-5 grid gap-4 border-t border-[var(--grid)] pt-4 text-xs leading-relaxed text-[var(--muted)] sm:grid-cols-3">259 <p>260 <strong className="font-medium text-[var(--ink-2)]">Substitution</strong> — the261 headline: capability discounted by cost, barriers and adoption.262 </p>263 <p>264 <strong className="font-medium text-[var(--ink-2)]">Exposure</strong> —265 technical capability alone, regardless of whether anyone deploys it.266 </p>267 <p>268 <strong className="font-medium text-[var(--ink-2)]">Augmentation</strong> — how269 much AI assists without replacing. High here + moderate substitution = a270 changing job, not a disappearing one.271 </p>272 </div>273 </div>274 <div className="mt-4 grid gap-4 lg:grid-cols-2">275 <div className="card min-w-0 p-5">276 <h3 className="text-sm font-semibold">Tasks on the substitution scale</h3>277 <p className="mt-0.5 text-xs text-[var(--muted)]">278 {taskScores.length} rated tasks, binned by substitution score.279 </p>280 <div className="mt-4">281 <DistributionChart bins={toBins(taskScores.map((t) => t.substitution))} />282 </div>283 </div>284 <div className="card min-w-0 p-5">285 <h3 className="text-sm font-semibold">Position among all scored occupations</h3>286 <p className="mt-0.5 text-xs text-[var(--muted)]">287 Distribution of {indexScores.length.toLocaleString("en-US")} occupation scores;288 the marker is this occupation.289 </p>290 <div className="mt-4">291 <DistributionChart292 bins={toBins(indexScores.map((s) => s.substitution))}293 marker={score.substitution}294 markerLabel={rank != null ? `#${rank} · ${score.substitution.toFixed(0)}` : score.substitution.toFixed(0)}295 />296 </div>297 </div>298 </div>299 <div className="mt-4">300 <ShareMeter301 share={score.highlyExposedTaskShare}302 label={`Tasks with substitution ≥ ${HIGH_EXPOSURE_THRESHOLD}`}303 />304 </div>305 <p className="mt-3 text-xs text-[var(--muted)]">306 Run {score.run.indexVersion} · computed {score.run.createdAt.toISOString().slice(0, 10)} ·307 rater panel: {score.run.raterModels.join(", ")} · intervals span rater disagreement.308 </p>309 </section>310311 {dimensions.length > 0 && (312 <section className="mt-12">313 <h2 className="text-xl font-semibold tracking-tight">Why this score</h2>314 <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">315 The five weighted dimensions of the composite, averaged across this occupation's316 tasks (importance-weighted, panel mean). Exact weights and formulas:{" "}317 <Link href="/api/v1/methodology" className="underline">318 /api/v1/methodology319 </Link>320 .321 </p>322 <div className="mt-5 overflow-hidden card">323 {dimensions.map((dim) => (324 <div key={dim.dimension} className="border-b border-[var(--grid)] px-5 py-3.5 last:border-b-0">325 <div className="flex flex-wrap items-baseline gap-2">326 <span className="text-sm font-medium">327 {DIMENSION_LABELS[dim.dimension]}328 </span>329 <span className="rounded-full bg-[var(--seq-track)] px-2 py-0.5 text-[10px] font-semibold tabular-nums">330 w {(dim.weight * 100).toFixed(0)}%331 </span>332 {dim.inverted && (333 <span className="text-xs text-[var(--muted)]">334 inverted — strong barriers lower the score335 </span>336 )}337 <span className="ml-auto text-sm font-semibold tabular-nums">338 {dim.pressure.toFixed(0)}339 </span>340 </div>341 <div aria-hidden="true" className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]">342 <div343 className="h-full rounded-r-[3px] bg-[var(--seq)]"344 style={{ width: `${Math.min(100, dim.pressure)}%` }}345 />346 </div>347 <p className="mt-1 text-xs text-[var(--muted)]">348 panel mean rating {dim.meanRating.toFixed(1)}/5349 {dim.inverted350 ? " (barrier strength) → substitution pressure "351 : " → substitution pressure "}352 {dim.pressure.toFixed(0)}/100353 </p>354 </div>355 ))}356 </div>357 </section>358 )}359360 <section className="mt-12">361 <h2 className="text-xl font-semibold tracking-tight">362 Task breakdown{" "}363 <span className="text-sm font-normal text-[var(--muted)]">364 ({rankedTasks.length} tasks)365 </span>366 </h2>367 <p className="mt-1 text-sm text-[var(--ink-2)]">368 Substitution pressure per task, weighted by O*NET importance in the composite.369 Expand a task for the full rater audit trail — every rating, every model, every370 rationale.371 </p>372 <div className="mt-5 overflow-hidden card">373 {rankedTasks.map((task) => {374 const ts = byTask.get(task.id);375 const taskRatings = ratingsByTask.get(task.id) ?? [];376 return (377 <details key={task.id} className="group border-b border-[var(--grid)] last:border-b-0">378 <summary className="cursor-pointer list-none px-5 py-4 hover:bg-[var(--wash)]">379 <div className="flex items-baseline gap-3">380 <p className="text-sm">{task.statement}</p>381 <span className="ml-auto shrink-0 pl-3 text-sm font-semibold tabular-nums">382 {ts ? ts.substitution.toFixed(0) : "—"}383 </span>384 </div>385 <div className="mt-2">386 {ts ? (387 <ScoreBar388 band={{389 low: ts.substitutionLow,390 score: ts.substitution,391 high: ts.substitutionHigh,392 }}393 thick={8}394 />395 ) : (396 <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" />397 )}398 </div>399 {ts && (400 <p className="mt-1.5 text-xs text-[var(--muted)]">401 CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} ·402 exposure {ts.exposure.toFixed(0)} · augmentation{" "}403 {ts.augmentation.toFixed(0)}404 {task.importance != null && <> · importance {task.importance.toFixed(1)}/5</>}405 {taskRatings.length > 0 && <> · click for rater detail</>}406 </p>407 )}408 </summary>409 {taskRatings.length > 0 && (410 <div className="overflow-x-auto border-t border-[var(--grid)] bg-[var(--page)] px-5 py-4">411 <table className="w-full min-w-[480px] text-left text-xs">412 <caption className="sr-only">413 Panel ratings per dimension for this task414 </caption>415 <thead className="text-[var(--muted)]">416 <tr>417 <th className="py-1 pr-3 font-medium">Dimension</th>418 <th className="py-1 pr-3 font-medium">Model</th>419 <th className="py-1 pr-3 font-medium">Rating</th>420 <th className="py-1 font-medium">Rationale</th>421 </tr>422 </thead>423 <tbody className="align-top">424 {taskRatings.map((row, index) => (425 <tr426 key={`${row.dimension}-${row.model}-${index}`}427 className="border-t border-[var(--grid)]"428 >429 <td className="py-1.5 pr-3 whitespace-nowrap">430 {DIMENSION_LABELS[row.dimension as DimensionKey] ?? row.dimension}431 </td>432 <td className="py-1.5 pr-3 whitespace-nowrap text-[var(--muted)]">433 {row.model}434 </td>435 <td className="py-1.5 pr-3 font-semibold tabular-nums">436 {row.rating}/5437 </td>438 <td className="py-1.5 text-[var(--ink-2)]">{row.rationale}</td>439 </tr>440 ))}441 </tbody>442 </table>443 </div>444 )}445 </details>446 );447 })}448 </div>449 </section>450451 {related.length > 0 && (452 <section className="mt-12">453 <h2 className="text-xl font-semibold tracking-tight">454 Related occupations — {socGroupName(occupation.code)}455 </h2>456 <ul className="mt-4 overflow-hidden card">457 {related.map((row) => (458 <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">459 <Link460 href={`/occupations/${row.occupationCode}`}461 className="flex items-baseline gap-3 px-5 py-3 hover:bg-[var(--wash)]"462 >463 <span className="min-w-0 truncate text-sm">{row.occupation.title}</span>464 <span className="ml-auto text-sm font-semibold tabular-nums">465 {row.substitution.toFixed(0)}466 </span>467 </Link>468 </li>469 ))}470 </ul>471 </section>472 )}473474 <section className="mt-12 grid gap-4 sm:grid-cols-2">475 <div className="card p-6">476 <h2 className="font-semibold">How to read this</h2>477 <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">478 A high substitution score does not mean this job disappears — it means a large479 share of its current tasks face replacement pressure, so the mix of tasks is480 likely to change. High augmentation alongside substitution typically means the481 occupation reorganizes around the protected tasks. Wide confidence intervals mean482 the rater panel disagreed: treat those scores as open questions, not verdicts.483 </p>484 </div>485 <div className="card p-6">486 <h2 className="font-semibold">What would change this score</h2>487 <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">488 New model capabilities (automatability, feasibility), falling inference costs489 (cost ratio), regulation and licensing shifts (barriers), and measured sector490 adoption (velocity) all re-enter at every index release. Each release is491 recomputed, versioned and kept queryable — scores are claims with a date on them,492 not permanent labels.493 </p>494 </div>495 </section>496 </>497 )}498 </main>499 );500}501